package org.carabiner.mockutil;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.jmock.core.Invocation;
import org.jmock.core.InvocationDispatcher;
import org.jmock.core.Invokable;
import org.jmock.core.Stub;
import org.jmock.core.Verifiable;
import org.jmock.core.stub.TestFailureStub;

/**
 * All of the work for the {@link BeanMock} is done here. The order of
 * operations when the method call is dispatched is as follows.
 * 
 * <ol>
 * <li>First, we mimick the
 * <code>org.jmock.core.LIFOInvocationDispatcher</code> to catch any
 * expectations that have been set on the {@link BeanMock} and return what the
 * expectation tells us to return.</li>
 * <li>If there is no expectation set, the method invocation will fall through
 * to our getter/setter processing code in
 * {@link #makeDynamicCall(Invocation)}.</li>
 * <li>Finally, if a getter or setter is not being invoked, we fall through to
 * the default stub. The default stub is
 * <code>org.jmock.core.stub.TestFailureStub</code>, but it can be changed
 * via its setter: {@link #setDefaultStub(Stub)}.</li>
 * </ol>
 * 
 * @author <a href="mailto:bignate@gmail.com">Nathan Eror</a>
 */
class BeanInvocationDispatcher implements InvocationDispatcher
{
  public static final String NO_EXPECTATIONS_MESSAGE = "No expectations set";

  private List invokables = new ArrayList();

  private Stub defaultStub = new TestFailureStub("no match found");

  private final Pattern getterPattern = Pattern.compile("^(get|is)(.+)");

  private final Pattern setterPattern = Pattern.compile("^set(.+)");

  private Map propertyMap = null;

  private Set declaredClasses = new HashSet();

  private static Map primitiveBoxingMap;

  private boolean defaultPrimitives;

  private boolean defaultInstances;

  /**
   * Create a default <code>BeanInvocationDispatcher</code> that returns null
   * for any getters that are not explicitly set.
   */
  public BeanInvocationDispatcher()
  {
    this(false, false);
  }

  public BeanInvocationDispatcher(boolean returnDefaultPrimitiveValues,
      boolean returnDefaultObjectInstances)
  {
    defaultPrimitives = returnDefaultPrimitiveValues;
    defaultInstances = returnDefaultObjectInstances;
  }

  public Object dispatch(Invocation invocation) throws Throwable
  {
    Class declaringClass = invocation.invokedMethod.getDeclaringClass();
    if (propertyMap == null)
      propertyMap = createPropertyMap(declaringClass);
    else if (!declaredClasses.contains(declaringClass))
      addPropertiesToMap(declaringClass, propertyMap);

    return dispatchLIFO(invocation);
  }

  private Object dispatchLIFO(Invocation invocation) throws Throwable
  {
    ListIterator i = invokables.listIterator(invokables.size());
    while (i.hasPrevious())
    {
      Invokable invokable = (Invokable) i.previous();
      if (invokable.matches(invocation))
      {
        return invokable.invoke(invocation);
      }
    }
    return makeDynamicCall(invocation);
  }

  private Object makeDynamicCall(Invocation invocation)
      throws IllegalArgumentException, Throwable
  {
    Method calledMethod = invocation.invokedMethod;
    Class declaringClass = calledMethod.getDeclaringClass();
    Method[] methods = declaringClass.getMethods();
    for (int i = 0; i < methods.length; i++)
    {
      Method method = methods[i];
      if (method.getName().equals(calledMethod.getName())
          && matchParameters(method.getParameterTypes(), calledMethod
              .getParameterTypes()))
      {
        Matcher getterMatcher = getterPattern.matcher(method.getName());
        Matcher setterMatcher = setterPattern.matcher(method.getName());
        if (getterMatcher.matches())
        {
          String property = getterMatcher.group(2);
          return getProperty(property, method.getReturnType());
        } else if (setterMatcher.matches())
        {
          String property = makePropertyString(setterMatcher.group(1));
          if (propertyMap.containsKey(property))
          {
            propertyMap.put(property, invocation.parameterValues.get(0));
            return null;
          } else
          {
            // This shouldn't happen because we've already checked for the
            // method in the constructor, but we'll throw a nasty runtime
            // exception just in case.
            throw new IllegalArgumentException(
                "There is no "
                    + property
                    + " property!  If this happened, then something is seriously wrong.");
          }
        }
      }
    }
    return defaultStub.invoke(invocation);
  }

  /**
   * Get the object value of the specified property from the {@link #propertyMap}. If the
   * result is null, check to see if the return type is a primitive. If so,
   * return a new instance of the primitives Object type.  This will be an
   * instance equal to 0 for number types and false for boolean. If the return
   * type is neither Number or Boolean, see if it has a default constructor. If
   * there is a default constructor, return a new default instance of the
   * object, otherwise return null.
   * 
   * @param property
   * @param returnType
   * @return
   * @throws IllegalArgumentException
   */
  private Object getProperty(String property, Class returnType)
      throws IllegalArgumentException
  {
    String theProperty = makePropertyString(property);
    Object returnObj = null;

    if (propertyMap.containsKey(theProperty))
    {
      returnObj = propertyMap.get(theProperty);
    } else
    {
      // This shouldn't happen because we've already checked for the
      // method in the constructor, but we'll throw a nasty runtime
      // exception just in case.
      throw new IllegalArgumentException("There is no " + theProperty
          + " property!  If this happened, then something is seriously wrong.");
    }

    if (returnObj == null)
    {
      returnType = forceToObject(returnType);
      if (defaultPrimitives)
      {
        if (returnType.getSuperclass().equals(Number.class))
        {
          returnObj = returnNewNumber(property, returnType);
        } else if (returnType.equals(Boolean.class))
        {
          returnObj = new Boolean(false);
        }
      }
      if (defaultInstances)
      {
        // Check to see it there is a default constructor for the return type.
        // If there is, use newInstance() to construct an instance of the object
        // to be returned. Otherwise, return null.
        Constructor[] constructors = returnType.getConstructors();
        for (int i = 0; i < constructors.length; i++)
        {
          Constructor constructor = constructors[i];
          if (constructor.getParameterTypes().length == 0
              && constructor.getModifiers() == Modifier.PUBLIC)
          {
            try
            {
              returnObj = constructor.newInstance(new Object[] {});
            } catch (Exception ex)
            {
              // This should never happen since we've already determined that
              // there is a public default constructor.  Again, we throw a 
              // nasty runtime exception to make sure
              throw new RuntimeException("There was no public default constructor.  I don't know what else to do!");
            }
          }
        }
      }
    }
    return returnObj;
  }

  /**
   * Returns a map that maps from java primitive classes to theier respective
   * Java Object classes. For example getPrimitiveBoxingMap().get(int.class)
   * would return Integer.class.
   * 
   * @return Object
   */
  private static Map getPrimitiveBoxingMap()
  {
    if (primitiveBoxingMap == null)
    {
      primitiveBoxingMap = new HashMap();
      primitiveBoxingMap.put(boolean.class, Boolean.class);
      primitiveBoxingMap.put(byte.class, Byte.class);
      primitiveBoxingMap.put(char.class, Character.class);
      primitiveBoxingMap.put(short.class, Short.class);
      primitiveBoxingMap.put(int.class, Integer.class);
      primitiveBoxingMap.put(long.class, Long.class);
      primitiveBoxingMap.put(float.class, Float.class);
      primitiveBoxingMap.put(double.class, Double.class);
    }
    return primitiveBoxingMap;
  }

  /**
   * Converts primitive type Class objects to thier Object based counterpart, if
   * clazz is not already an Object type.
   * 
   * @param clazz
   *          Class
   * @return Class
   */
  public static Class forceToObject(Class clazz)
  {
    if (getPrimitiveBoxingMap().containsKey(clazz))
    {
      return (Class) getPrimitiveBoxingMap().get(clazz);
    }
    return clazz;
  }

  private Object returnNewNumber(String property, Class returnType)
      throws RuntimeException
  {
    Object returnObj = null;
    try
    {
      returnObj = returnType.getConstructor(new Class[] { String.class })
          .newInstance(new Object[] { "0" });
    } catch (IllegalAccessException ex)
    {
      throw new RuntimeException("Unable to access the property " + property,
          ex);
    } catch (InstantiationException ex)
    {
      throw new RuntimeException("Unable to instantiate the property type "
          + returnType, ex);
    } catch (SecurityException ex)
    {
      throw new RuntimeException("Unable to access the property " + property,
          ex);
    } catch (NoSuchMethodException ex)
    {
      throw new RuntimeException("Unable to access the constructor for "
          + property, ex);
    } catch (InvocationTargetException ex)
    {
      throw new RuntimeException("Unable to instantiate the " + property, ex);
    } catch (IllegalArgumentException ex)
    {
      throw new RuntimeException("Unable to instantiate the " + property, ex);
    }
    return returnObj;
  }

  private Map createPropertyMap(Class declaringClass)
  {
    Map theMap = new HashMap();
    addPropertiesToMap(declaringClass, theMap);
    return theMap;
  }

  private void addPropertiesToMap(Class declaringClass, Map theMap)
  {
    declaredClasses.add(declaringClass);
    for (Iterator iter = makeGetterAndSetterList(declaringClass.getMethods())
        .iterator(); iter.hasNext();)
    {
      String propertyName = (String) iter.next();
      theMap.put(propertyName, null);
    }
  }

  private List makeGetterAndSetterList(Method[] allPublicMethods)
  {
    List theList = new ArrayList();
    for (int i = 0; i < allPublicMethods.length; i++)
    {
      Matcher getterMatcher = getterPattern.matcher(allPublicMethods[i]
          .getName());
      Matcher setterMatcher = setterPattern.matcher(allPublicMethods[i]
          .getName());
      if (getterMatcher.matches())
      {
        String property = getterMatcher.group(2);
        theList.add(makePropertyString(property));
      } else if (setterMatcher.matches())
      {
        String property = setterMatcher.group(1);
        theList.add(makePropertyString(property));
      }
    }
    return theList;
  }

  private boolean matchParameters(Class[] declaredParameters,
      Class[] calledParameters)
  {
    boolean paramsMatch = false;
    if (declaredParameters.length == 0 && calledParameters.length == 0)
    {
      paramsMatch = true;
    } else if (declaredParameters.length == calledParameters.length)
    {
      for (int i = 0; i < declaredParameters.length; i++)
      {
        paramsMatch = (declaredParameters[i].equals(calledParameters[i])) ? true
            : false;
      }
    }
    return paramsMatch;
  }

  private String makePropertyString(String property)
  {
    String fletter = property.substring(0, 1).toLowerCase();
    String rest = property.substring(1);
    return (fletter + rest);
  }

  public void verify()
  {
    Iterator i = invokables.iterator();
    while (i.hasNext())
    {
      ((Verifiable) i.next()).verify();
    }
  }

  public void setDefaultStub(Stub newDefaultStub)
  {
    defaultStub = newDefaultStub;
  }

  public void add(Invokable newInvokable)
  {
    invokables.add(newInvokable);
  }

  public void clear()
  {
    invokables.clear();
  }

  public StringBuffer describeTo(StringBuffer buffer)
  {
    if (anyInvokableHasDescription())
    {
      writeInvokablesTo(buffer);
    } else
    {
      buffer.append(NO_EXPECTATIONS_MESSAGE);
    }

    return buffer;
  }

  private void writeInvokablesTo(StringBuffer buffer)
  {
    Iterator iterator = invokables.iterator();
    while (iterator.hasNext())
    {
      Invokable invokable = (Invokable) iterator.next();
      if (invokable.hasDescription())
      {
        invokable.describeTo(buffer).append("\n");
      }
    }
  }

  private boolean anyInvokableHasDescription()
  {
    Iterator iterator = invokables.iterator();
    while (iterator.hasNext())
    {
      if (((Invokable) iterator.next()).hasDescription())
        return true;
    }
    return false;
  }

}
